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