1# Copyright (C) 2008-2010 Adam Olsen 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16# 17# 18# The developers of the Exaile media player hereby grant permission 19# for non-GPL compatible GStreamer and Exaile plugins to be used and 20# distributed together with GStreamer and Exaile. This permission is 21# above and beyond the permissions granted by the GPL license by which 22# Exaile is covered. If you modify this code, you may extend this 23# exception to your version of the code, but you are not obligated to 24# do so. If you do not wish to do so, delete this exception statement 25# from your version. 26 27from datetime import datetime, timedelta 28import os 29import re 30import zlib 31import threading 32 33from xl.nls import gettext as _ 34from xl.trax import Track 35from xl import common, event, providers, settings, xdg 36 37 38class LyricsNotFoundException(Exception): 39 pass 40 41 42class LyricsCache: 43 """ 44 Basically just a thread-safe shelf for convinience. 45 Supports container syntax. 46 """ 47 48 def __init__(self, location, default=None): 49 """ 50 @param location: specify the shelve file location 51 52 @param default: can specify a default to return from getter when 53 there is nothing in the shelve 54 """ 55 self.location = location 56 self.db = common.open_shelf(location) 57 self.lock = threading.Lock() 58 self.default = default 59 60 # Callback to close db 61 event.add_callback(self.on_quit_application, 'quit_application') 62 63 def on_quit_application(self, *args): 64 """ 65 Closes db on quit application 66 Gets the lock/wait operations 67 """ 68 with self.lock: 69 self.db.close() 70 71 def keys(self): 72 """ 73 Return the shelve keys 74 """ 75 return self.db.keys() 76 77 def _get(self, key, default=None): 78 with self.lock: 79 try: 80 return self.db[key] 81 except Exception: 82 return default if default is not None else self.default 83 84 def _set(self, key, value): 85 with self.lock: 86 self.db[key] = value 87 # force save, wasn't auto-saving... 88 self.db.sync() 89 90 def __getitem__(self, key): 91 return self._get(key) 92 93 def __setitem__(self, key, value): 94 self._set(key, value) 95 96 def __contains__(self, key): 97 return key in self.db 98 99 def __delitem__(self, key): 100 with self.lock: 101 del self.db[key] 102 103 def __iter__(self): 104 return self.db.__iter__() 105 106 def __len__(self): 107 return len(self.db) 108 109 110class LyricsManager(providers.ProviderHandler): 111 """ 112 Lyrics Manager 113 114 Manages talking to the lyrics plugins and updating the track 115 """ 116 117 def __init__(self): 118 providers.ProviderHandler.__init__(self, "lyrics") 119 self.preferred_order = settings.get_option('lyrics/preferred_order', []) 120 self.cache = LyricsCache(os.path.join(xdg.get_cache_dir(), 'lyrics.cache')) 121 122 event.add_callback(self.on_track_tags_changed, 'track_tags_changed') 123 124 def __get_cache_key(self, track: Track, provider) -> str: 125 """ 126 Returns the cache key for a specific track and lyrics provider 127 128 :param track: a track 129 :param provider: a lyrics provider 130 :return: the appropriate cache key 131 """ 132 return ( 133 track.get_loc_for_io() 134 + provider.display_name 135 + track.get_tag_display('artist') 136 + track.get_tag_display('title') 137 ) 138 139 def set_preferred_order(self, order): 140 """ 141 Sets the preferred search order 142 143 :param order: a list containing the order you'd like to search 144 first 145 """ 146 if not type(order) in (list, tuple): 147 raise AttributeError("order must be a list or tuple") 148 self.preferred_order = order 149 settings.set_option('lyrics/preferred_order', list(order)) 150 151 def find_lyrics(self, track, refresh=False): 152 """ 153 Fetches lyrics for a track either from 154 1. a backend lyric plugin 155 2. the actual tags in the track 156 157 :param track: the track we want lyrics for, it 158 must have artist/title tags 159 160 :param refresh: if True, try to refresh cached data even if 161 not expired 162 163 :return: tuple of the following format (lyrics, source, url) 164 where lyrics are the lyrics to the track 165 source is where it came from (file, lyrics wiki, 166 lyrics fly, etc.) 167 url is a link to the lyrics (where applicable) 168 169 :raise LyricsNotFoundException: when lyrics are not 170 found 171 """ 172 lyrics = None 173 source = None 174 url = None 175 176 for method in self.get_providers(): 177 try: 178 (lyrics, source, url) = self._find_cached_lyrics(method, track, refresh) 179 except LyricsNotFoundException: 180 continue 181 break 182 else: 183 # This only happens if all providers raised LyricsNotFoundException. 184 raise LyricsNotFoundException() 185 186 lyrics = lyrics.strip() 187 188 return (lyrics, source, url) 189 190 def find_all_lyrics(self, track, refresh=False): 191 """ 192 Like find_lyrics but fetches all sources and returns 193 a list of lyrics. 194 195 :param track: the track we want lyrics for, it 196 must have artist/title tags 197 198 :param refresh: if True, try to refresh cached data even if 199 not expired 200 201 :return: list of tuples in the same format as 202 find_lyrics's return value 203 204 :raise LyricsNotFoundException: when lyrics are not 205 found from all sources. 206 """ 207 lyrics_found = [] 208 209 for method in self.get_providers(): 210 lyrics = None 211 source = None 212 url = None 213 try: 214 (lyrics, source, url) = self._find_cached_lyrics(method, track, refresh) 215 except LyricsNotFoundException: 216 continue 217 lyrics = lyrics.strip() 218 lyrics_found.append((method.display_name, lyrics, source, url)) 219 220 if not lyrics_found: 221 # no lyrics were found, raise an exception 222 raise LyricsNotFoundException() 223 224 return lyrics_found 225 226 def _find_cached_lyrics(self, method, track, refresh=False): 227 """ 228 Checks the cache for lyrics. If found and not expired, returns 229 cached results, otherwise tries to fetch from method. 230 231 :param method: the LyricSearchMethod to fetch lyrics from. 232 233 :param track: the track we want lyrics for, it 234 must have artist/title tags 235 236 :param refresh: if True, try to refresh cached data even if 237 not expired 238 239 :return: list of tuples in the same format as 240 find_lyric's return value 241 242 :raise LyricsNotFoundException: when lyrics are not found 243 in cache or fetched from method 244 """ 245 lyrics = None 246 source = None 247 url = None 248 cache_time = settings.get_option('lyrics/cache_time', 720) # in hours 249 key = self.__get_cache_key(track, method) 250 251 # check cache for lyrics 252 if key in self.cache: 253 (lyrics, source, url, time) = self.cache[key] 254 # return if they are not expired 255 now = datetime.now() 256 if now - time < timedelta(hours=cache_time) and not refresh: 257 try: 258 lyrics = zlib.decompress(lyrics) 259 except zlib.error as e: 260 raise LyricsNotFoundException(e) 261 return (lyrics.decode('utf-8', errors='replace'), source, url) 262 263 (lyrics, source, url) = method.find_lyrics(track) 264 assert isinstance(lyrics, str), (method, track) 265 266 # update cache 267 time = datetime.now() 268 self.cache[key] = (zlib.compress(lyrics.encode('utf-8')), source, url, time) 269 270 return (lyrics, source, url) 271 272 def on_provider_removed(self, provider): 273 """ 274 Remove the provider from the methods dict, and the 275 preferred_order dict if needed. 276 277 :param provider: the provider instance being removed. 278 """ 279 try: 280 self.preferred_order.remove(provider.name) 281 except (ValueError, AttributeError): 282 pass 283 284 def on_track_tags_changed(self, e, track, tags): 285 """ 286 Updates the internal cache upon lyric tag changes 287 """ 288 if 'lyrics' in tags: 289 local_provider = self.get_provider('__local') 290 291 # If the local tag provider was removed, don't bother 292 if local_provider is None: 293 return 294 295 key = self.__get_cache_key(track, local_provider) 296 297 # Try to remove the corresponding cache entry 298 try: 299 del self.cache[key] 300 except KeyError: 301 pass 302 303 304MANAGER = LyricsManager() 305 306 307class LyricSearchMethod: 308 """ 309 Lyrics plugins will subclass this 310 """ 311 312 def find_lyrics(self, track): 313 """ 314 Called by LyricsManager when lyrics are requested 315 316 :param track: the track that we want lyrics for 317 :return: tuple of lyrics text, provider name, URL 318 :rtype: Tuple[unicode, basestring, basestring] 319 :raise: LyricsNotFoundException if not found 320 """ 321 raise NotImplementedError 322 323 def _set_manager(self, manager): 324 """ 325 Sets the lyrics manager. 326 327 Called when this method is added to the lyrics manager. 328 329 :param manager: the lyrics manager 330 """ 331 self.manager = manager 332 333 def remove_script(self, data): 334 p = re.compile(r'<script.*/script>') 335 return p.sub('', data) 336 337 def remove_div(self, data): 338 p = re.compile(r'<div.*/div>') 339 return p.sub('', data) 340 341 def remove_html_tags(self, data): 342 data = data.replace('<br/>', '\n') 343 p = re.compile(r'<[^<]*?/?>') 344 data = p.sub('', data) 345 p = re.compile(r'/<!--.*?-->/') 346 return p.sub('', data) 347 348 349class LocalLyricSearch(LyricSearchMethod): 350 351 name = "__local" 352 display_name = _("Local") 353 354 def find_lyrics(self, track): 355 lyrics = track.get_tag_disk('lyrics') 356 if not lyrics: 357 raise LyricsNotFoundException() 358 return (lyrics[0], self.name, "") 359 360 361providers.register('lyrics', LocalLyricSearch()) 362