1# Copyright (C) 2008-2010 Adam Olsen
2# Copyright (C) 2018 Johannes Sasongko <sasongko@gmail.com>
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2, or (at your option)
7# any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program; if not, write to the Free Software
16# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17#
18#
19# The developers of the Exaile media player hereby grant permission
20# for non-GPL compatible GStreamer and Exaile plugins to be used and
21# distributed together with GStreamer and Exaile. This permission is
22# above and beyond the permissions granted by the GPL license by which
23# Exaile is covered. If you modify this code, you may extend this
24# exception to your version of the code, but you are not obligated to
25# do so. If you do not wish to do so, delete this exception statement
26# from your version.
27
28"""
29Provides the base for obtaining and storing covers, also known
30as album art.
31"""
32
33from gi.repository import GLib
34from gi.repository import Gio
35import logging
36import hashlib
37import os
38import pickle
39from typing import Optional
40
41from xl.nls import gettext as _
42from xl import common, event, providers, settings, trax, xdg
43
44logger = logging.getLogger(__name__)
45
46
47# TODO: maybe this could go into common.py instead? could be
48# useful in other areas.
49class Cacher:
50    """
51    Simple on-disk cache.
52
53    Note that as entries are stored as
54    individual files, the data being stored should be of significant
55    size (several KB) or a lot of disk space will likely be wasted.
56    """
57
58    def __init__(self, cache_dir):
59        """
60        :param cache_dir: directory to use for the cache. will be
61            created if it does not exist.
62        """
63        try:
64            os.makedirs(cache_dir)
65        except OSError:
66            pass
67        self.cache_dir = cache_dir
68
69    def add(self, data):
70        """
71        Adds an entry to the cache.  Returns a key that can be used
72        to retrieve the data from the cache.
73
74        :param data: The data to store, as a bytestring.
75        """
76        # FIXME: this doesnt handle hash collisions at all. with
77        # 2^256 possible keys its unlikely that we'll have a collision,
78        # but we should handle it anyway.
79        h = hashlib.sha256()
80        h.update(data)
81        key = h.hexdigest()
82        path = os.path.join(self.cache_dir, key)
83        with open(path, "wb") as fp:
84            fp.write(data)
85        return key
86
87    def remove(self, key):
88        """
89        Remove an entry from the cache.
90
91        :param key: The key to remove data for.
92        """
93        path = os.path.join(self.cache_dir, key)
94        try:
95            os.remove(path)
96        except OSError:
97            pass
98
99    def get(self, key):
100        """
101        Retrieve an entry from the cache.  Returns None if the given
102        key does not exist.
103
104        :param key: The key to retrieve data for.
105        """
106        path = os.path.join(self.cache_dir, key)
107        if os.path.exists(path):
108            with open(path, "rb") as fp:
109                return fp.read()
110        return None
111
112
113class CoverManager(providers.ProviderHandler):
114    """
115    Handles finding covers from various sources.
116    """
117
118    DB_VERSION = 2
119
120    def __init__(self, location):
121        """
122        :param location: The directory to load and store data in.
123        """
124        providers.ProviderHandler.__init__(self, "covers")
125        self.__cache = Cacher(os.path.join(location, 'cache'))
126        self.location = location
127        self.methods = {}
128        self.order = settings.get_option('covers/preferred_order', [])
129        self.db = {'version': self.DB_VERSION}
130        self.load()
131        for method in self.get_providers():
132            self.on_provider_added(method)
133
134        with open(xdg.get_data_path('images', 'nocover.png'), 'rb') as f:
135            self.default_cover_data = f.read()
136
137        self.tag_fetcher = TagCoverFetcher()
138        self.localfile_fetcher = LocalFileCoverFetcher()
139
140        if settings.get_option('covers/use_tags', True):
141            providers.register('covers', self.tag_fetcher)
142        if settings.get_option('covers/use_localfile', True):
143            providers.register('covers', self.localfile_fetcher)
144
145        event.add_callback(self._on_option_set, 'covers_option_set')
146
147    def _on_option_set(self, name, obj, data):
148        if data == "covers/use_tags":
149            if settings.get_option("covers/use_tags"):
150                providers.register('covers', self.tag_fetcher)
151            else:
152                providers.unregister('covers', self.tag_fetcher)
153        elif data == "covers/use_localfile":
154            if settings.get_option("covers/use_localfile"):
155                providers.register('covers', self.localfile_fetcher)
156            else:
157                providers.unregister('covers', self.localfile_fetcher)
158
159    def _get_methods(self, fixed=False):
160        """
161        Returns a list of Methods, sorted by preference
162
163        :param fixed: If true, include fixed-position backends in the
164                returned list.
165        """
166        methods = []
167        for name in self.order:
168            if name in self.methods:
169                methods.append(self.methods[name])
170        for k, method in self.methods.items():
171            if method not in methods:
172                methods.append(method)
173        nonfixed = [m for m in methods if not m.fixed]
174        if fixed:
175            fixed = [m for m in methods if m.fixed]
176            fixed.sort(key=lambda x: x.fixed_priority)
177            for i, v in enumerate(fixed):
178                if v.fixed_priority > 50:
179                    methods = fixed[:i] + nonfixed + fixed[i:]
180                    break
181            else:
182                methods = fixed + nonfixed
183        else:
184            methods = nonfixed
185        return methods
186
187    @staticmethod
188    def _get_track_key(track: trax.Track) -> Optional[str]:
189        """Get a unique, hashable identifier for the track's album.
190
191        If the track has no album identifier, this method returns None.
192        """
193
194        # The output is in the form
195        #   'tag1  \0  value1a \1 value1b  \0  tag2  \0  value2'
196        # without the spaces.
197        #
198        # Possible tag combinations, in order of preference:
199        #   * musicbrainz_albumid
200        #   * album albumartist [date]
201        #   * __compilation [date]
202        #   * album [artist] [date]
203
204        def _get_pair(tag: str) -> Optional[str]:
205            value = track.get_tag_raw(tag)
206            if not value:
207                return None
208            value = '\1'.join(value)
209            return tag + '\0' + value
210
211        albumid = _get_pair('musicbrainz_albumid')
212        if albumid:
213            return albumid
214
215        album = _get_pair('album')
216        if not album:
217            return None
218
219        albumartist = _get_pair('albumartist')
220        if albumartist:
221            dbkey = album + '\0' + albumartist
222        else:
223            compilation = _get_pair('__compilation')
224            if compilation:
225                # compilation is directory+album, where the directory mimics
226                # the role of albumartist.
227                dbkey = compilation
228            else:
229                dbkey = album
230                artist = _get_pair('artist')
231                if artist:
232                    dbkey += '\0' + artist
233        assert dbkey
234
235        date = _get_pair('date')
236        if date:
237            dbkey += '\0' + date
238
239        return dbkey
240
241    def get_db_string(self, track: trax.Track) -> Optional[str]:
242        """
243        Returns the internal string used to map the cover
244        to a track
245
246        :param track: the track to retrieve the string for
247        :type track: :class:`xl.trax.Track`
248        :returns: the internal identifier string
249        """
250        key = self._get_track_key(track)
251        if key is None:
252            return None
253
254        return self.db.get(key)
255
256    @common.synchronized
257    @common.cached(5)
258    def find_covers(self, track, limit=-1, local_only=False):
259        """
260        Find all covers for a track
261
262        :param track: The track to find covers for
263        :param limit: maximum number of covers to return. -1=unlimited.
264        :param local_only: If True, will only return results from local
265                sources.
266        """
267        if track is None:
268            return
269        covers = []
270        for method in self._get_methods(fixed=True):
271            if local_only and method.use_cache:
272                continue
273            new = method.find_covers(track, limit=limit)
274            new = ["%s:%s" % (method.name, x) for x in new]
275            covers.extend(new)
276            if limit != -1 and len(covers) >= limit:
277                break
278        return covers
279
280    def set_cover(self, track, db_string, data=None):
281        """
282        Sets the cover for a track. This will overwrite any existing
283        entry.
284
285        :param track: The track to set the cover for
286        :param db_string: the string identifying the source of the
287                cover, in "method:key" format.
288        :param data: The raw cover data to store for the track.  Will
289                only be stored if the method has use_cache=True
290        """
291        name = db_string.split(":", 1)[0]
292        method = self.methods.get(name)
293        if method and method.use_cache and data:
294            db_string = "cache:%s" % self.__cache.add(data)
295        key = self._get_track_key(track)
296        if key:
297            self.db[key] = db_string
298            self.timeout_save()
299            event.log_event('cover_set', self, track)
300
301    def remove_cover(self, track):
302        """
303        Remove the saved cover entry for a track, if it exists.
304        """
305        if track is None:
306            return
307        key = self._get_track_key(track)
308        if key is None:
309            return
310        db_string = self.get_db_string(track)
311        if db_string is None:
312            return
313        del self.db[key]
314        self.__cache.remove(db_string)
315        self.timeout_save()
316        event.log_event('cover_removed', self, track)
317
318    def get_cover(self, track, save_cover=True, set_only=False, use_default=False):
319        """
320        get the cover for a given track.
321        if the track has no set cover, backends are
322        searched until a cover is found or we run out of backends.
323
324        :param track: the Track to get the cover for.
325        :param save_cover: if True, a set_cover call will be made
326                to store the cover for later use.
327        :param set_only: Only retrieve covers that have been set
328                in the db.
329        :param use_default: If True, returns the default cover instead
330                of None when no covers are found.
331        """
332        if track is None:
333            return self.get_default_cover() if use_default else None
334
335        db_string = self.get_db_string(track)
336        if db_string:
337            cover = self.get_cover_data(db_string, use_default=use_default)
338            if cover:
339                return cover
340
341        if set_only:
342            return self.get_default_cover() if use_default else None
343
344        covers = self.find_covers(track, limit=1)
345        if covers:
346            cover = covers[0]
347            data = self.get_cover_data(cover, use_default=use_default)
348            if save_cover and data != self.get_default_cover():
349                self.set_cover(track, cover, data)
350            return data
351
352        return self.get_default_cover() if use_default else None
353
354    def get_cover_data(self, db_string, use_default=False):
355        """
356        Get the raw image data for a cover.
357
358        :param db_string: The db_string identifying the cover to get.
359        :param use_default: If True, returns the default cover instead
360                of None when no covers are found.
361        """
362        source, data = db_string.split(":", 1)
363        ret = None
364        if source == "cache":
365            ret = self.__cache.get(data)
366        else:
367            method = self.methods.get(source)
368            if method:
369                ret = method.get_cover_data(data)
370        if ret is None and use_default is True:
371            ret = self.get_default_cover()
372        return ret
373
374    def get_default_cover(self):
375        """
376        Get the raw image data for the cover to show if there is no
377        cover to display.
378        """
379        # TODO: wrap this into get_cover_data and get_cover somehow?
380        return self.default_cover_data
381
382    def load(self):
383        """
384        Load the saved db
385        """
386        path = os.path.join(self.location, 'covers.db')
387        data = None
388        for loc in [path, path + ".old", path + ".new"]:
389            try:
390                with open(loc, 'rb') as f:
391                    data = pickle.load(f)
392            except IOError:
393                pass
394            except EOFError:
395                try:
396                    os.remove(loc)
397                except Exception:
398                    pass
399            if data:
400                break
401        if data:
402            self.db = data
403        version = self.db.get('version', 1)
404        if version > self.DB_VERSION:
405            logger.error(
406                "covers.db version (%s) higher than supported (%s); using anyway",
407                version,
408                self.DB_VERSION,
409            )
410
411    @common.glib_wait_seconds(60)
412    def timeout_save(self):
413        self.save()
414
415    def save(self):
416        """
417        Save the db
418        """
419        path = os.path.join(self.location, 'covers.db')
420        try:
421            with open(path + ".new", 'wb') as f:
422                pickle.dump(self.db, f, common.PICKLE_PROTOCOL)
423        except IOError:
424            return
425        try:
426            os.rename(path, path + ".old")
427        except OSError:
428            pass  # if it doesn'texist we don't care
429        os.rename(path + ".new", path)
430        try:
431            os.remove(path + ".old")
432        except OSError:
433            pass
434
435    def on_provider_added(self, provider):
436        self.methods[provider.name] = provider
437        if provider.name not in self.order:
438            self.order.append(provider.name)
439
440    def on_provider_removed(self, provider):
441        try:
442            del self.methods[provider.name]
443        except KeyError:
444            pass
445        if provider.name in self.order:
446            self.order.remove(provider.name)
447
448    def set_preferred_order(self, order):
449        """
450        Sets the preferred search order
451
452        :param order: a list containing the order you'd like to search
453            first
454        """
455        if not type(order) in (list, tuple):
456            raise TypeError("order must be a list or tuple")
457        self.order = order
458        settings.set_option('covers/preferred_order', list(order))
459
460    def get_cover_for_tracks(self, tracks, db_strings_to_ignore):
461        """
462        For tracks, try to find a cover
463        Basically returns the first cover found
464        :param tracks: list of tracks [xl.trax.Track]
465        :param db_strings_to_ignore: list [str]
466        :return: GdkPixbuf.Pixbuf or None if no cover found
467        """
468        for track in tracks:
469            db_string = self.get_db_string(track)
470            if db_string and db_string not in db_strings_to_ignore:
471                db_strings_to_ignore.append(db_string)
472                return self.get_cover_data(db_string)
473
474        return None  # No cover found
475
476
477class CoverSearchMethod:
478    """
479    Base class for creating cover search methods.
480
481    Search methods do not have to inherit from this class, it's
482    intended more as a template to demonstrate the needed interface.
483    """
484
485    #: If true, cover results will be cached for faster lookup
486    use_cache = True
487    #: A name uniquely identifing the search method.
488    name = "base"
489    #: Whether the backend should have a fixed priority instead of being
490    #  configurable.
491    fixed = False
492    #: Priority for fixed-position backends. Lower is earlier, non-fixed
493    #  backends will always be 50.
494    fixed_priority = 50
495
496    def find_covers(self, track, limit=-1):
497        """
498        Find the covers for a given track.
499
500        :param track: The track to find covers for.
501        :param limit: Maximal number of covers to return.
502        :returns: A list of strings that can be passed to get_cover_data.
503        """
504        raise NotImplementedError
505
506    def get_cover_data(self, db_string):
507        """
508        Get the image data for a cover
509
510        :param db_string: A method-dependent string that identifies the
511                cover to get.
512        """
513        raise NotImplementedError
514
515
516class TagCoverFetcher(CoverSearchMethod):
517    """
518    Cover source that looks for images embedded in tags.
519    """
520
521    use_cache = False
522    name = "tags"
523    title = _('Tags')
524    cover_tags = ["cover", "coverart"]
525    fixed = True
526    fixed_priority = 30
527
528    def find_covers(self, track, limit=-1):
529        covers = []
530        tagname = None
531        uri = track.get_loc_for_io()
532
533        for tag in self.cover_tags:
534            try:
535                # Force type conversion to list, fails for None
536                covers = list(track.get_tag_disk(tag))
537                tagname = tag
538                break
539            except (TypeError, KeyError):
540                pass
541
542        return [
543            '{tagname}:{index}:{uri}'.format(tagname=tagname, index=index, uri=uri)
544            for index in range(0, len(covers))
545        ]
546
547    def get_cover_data(self, db_string):
548        tag, index, uri = db_string.split(':', 2)
549        track = trax.Track(uri, scan=False)
550        covers = track.get_tag_disk(tag)
551
552        if not covers:
553            return None
554
555        return covers[int(index)].data
556
557
558class LocalFileCoverFetcher(CoverSearchMethod):
559    """
560    Cover source that looks for images in the same directory as the
561    Track.
562    """
563
564    use_cache = False
565    name = "localfile"
566    title = _('Local file')
567    uri_types = ['file', 'smb', 'sftp', 'nfs']
568    extensions = ['.png', '.jpg', '.jpeg', '.gif']
569    preferred_names = []
570    fixed = True
571    fixed_priority = 31
572
573    def __init__(self):
574        CoverSearchMethod.__init__(self)
575
576        event.add_callback(self.on_option_set, 'covers_localfile_option_set')
577        self.on_option_set(
578            'covers_localfile_option_set', settings, 'covers/localfile/preferred_names'
579        )
580
581    def find_covers(self, track, limit=-1):
582        # TODO: perhaps should instead check to see if its mounted in
583        # gio, rather than basing this on uri type. file:// should
584        # always be checked, obviously.
585        if track.get_type() not in self.uri_types:
586            return []
587        basedir = Gio.File.new_for_uri(track.get_loc_for_io()).get_parent()
588        try:
589            if (
590                not basedir.query_info(
591                    "standard::type", Gio.FileQueryInfoFlags.NONE, None
592                ).get_file_type()
593                == Gio.FileType.DIRECTORY
594            ):
595                return []
596        except GLib.Error:
597            return []
598        covers = []
599        for fileinfo in basedir.enumerate_children(
600            "standard::type" ",standard::name", Gio.FileQueryInfoFlags.NONE, None
601        ):
602            gloc = basedir.get_child(fileinfo.get_name())
603            if not fileinfo.get_file_type() == Gio.FileType.REGULAR:
604                continue
605            filename = gloc.get_basename()
606            base, ext = os.path.splitext(filename)
607            if ext.lower() not in self.extensions:
608                continue
609            if base in self.preferred_names:
610                covers.insert(0, gloc.get_uri())
611            else:
612                covers.append(gloc.get_uri())
613        if limit == -1:
614            return covers
615        else:
616            return covers[:limit]
617
618    def get_cover_data(self, db_string):
619        try:
620            data = Gio.File.new_for_uri(db_string).load_contents(None)[1]
621            return data
622        except GLib.GError:
623            return None
624
625    def on_option_set(self, e, settings, option):
626        """
627        Updates the internal settings upon option change
628        """
629        if option == 'covers/localfile/preferred_names':
630            self.preferred_names = settings.get_option(option, ['album', 'cover'])
631
632
633#: The singleton :class:`CoverManager` instance
634MANAGER = CoverManager(location=xdg.get_data_home_path("covers", check_exists=False))
635