1# Copyright 2013 Simonas Kazlauskas
2#      2014-2018 Nick Boultbee
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 of the License, or
7# (at your option) any later version.
8
9from itertools import chain
10
11from gi.repository import GObject
12
13from quodlibet import _
14from quodlibet.formats import AudioFile
15from quodlibet.plugins import PluginManager, PluginHandler
16from quodlibet.qltk.notif import Task
17from quodlibet.util.cover import built_in
18from quodlibet.util import print_d
19from quodlibet.util.thread import call_async
20from quodlibet.util.thumbnails import get_thumbnail_from_file
21from quodlibet.plugins.cover import CoverSourcePlugin
22
23
24class CoverPluginHandler(PluginHandler):
25    """A plugin handler for CoverSourcePlugin implementation"""
26
27    def __init__(self, use_built_in=True):
28        self.providers = set()
29        if use_built_in:
30            self.built_in = {built_in.EmbeddedCover, built_in.FilesystemCover}
31        else:
32            self.built_in = set()
33
34    def plugin_handle(self, plugin):
35        return issubclass(plugin.cls, CoverSourcePlugin)
36
37    def plugin_enable(self, plugin):
38        self.providers.add(plugin)
39        print_d("Registered {0} cover source".format(plugin.cls.__name__))
40
41    def plugin_disable(self, plugin):
42        self.providers.remove(plugin)
43        print_d("Unregistered {0} cover source".format(plugin.cls.__name__))
44
45    @property
46    def sources(self):
47        """Yields all active CoverSourcePlugin classes sorted by priority"""
48
49        sources = chain((p.cls for p in self.providers), self.built_in)
50        for p in sorted(sources, reverse=True, key=lambda x: x.priority()):
51            yield p
52
53
54class CoverManager(GObject.Object):
55
56    __gsignals__ = {
57        # ([AudioFile]), emitted if the cover for any songs might have changed
58        'cover-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)),
59
60        # Covers were found for the songs
61        'covers-found': (GObject.SignalFlags.RUN_LAST, None, (object, object)),
62
63        # All searches were submitted, and success by provider is sent
64        'searches-complete': (GObject.SignalFlags.RUN_LAST, None, (object,))
65    }
66
67    plugin_handler = None
68
69    def __init__(self, use_built_in=True):
70        super(CoverManager, self).__init__()
71        self.plugin_handler = CoverPluginHandler(use_built_in)
72
73    def init_plugins(self):
74        """Register the cover sources plugin handler with the global
75        plugin manager.
76        """
77
78        PluginManager.instance.register_handler(self.plugin_handler)
79
80    @property
81    def sources(self):
82        return self.plugin_handler.sources
83
84    def cover_changed(self, songs):
85        """Notify the world that the artwork for some songs or collections
86        containing that songs might have changed (For example a new image was
87        added to the folder or a new embedded image was added)
88
89        This will invalidate all caches and will notify others that they have
90        to re-fetch the cover and do a display update.
91        """
92
93        self.emit("cover-changed", songs)
94
95    def acquire_cover(self, callback, cancellable, song):
96        """
97        Try to get covers from all cover sources until a cover is found.
98
99        * callback(found, result) is the function which will be called when
100        this method completes its job.
101        * cancellable – Gio.Cancellable which will interrupt the search.
102        The callback won't be called when the operation is cancelled.
103        """
104        sources = self.sources
105
106        def success(source, result):
107            name = source.__class__.__name__
108            print_d('Successfully got cover from {0}'.format(name))
109            source.disconnect_by_func(success)
110            source.disconnect_by_func(failure)
111            if not cancellable or not cancellable.is_cancelled():
112                callback(True, result)
113
114        def failure(source, msg):
115            name = source.__class__.__name__
116            print_d("Didn't get cover from {0}: {1}".format(name, msg))
117            source.disconnect_by_func(success)
118            source.disconnect_by_func(failure)
119            if not cancellable or not cancellable.is_cancelled():
120                run()
121
122        def run():
123            try:
124                provider = next(sources)(song, cancellable)
125            except StopIteration:
126                return callback(False, None)  # No cover found
127
128            cover = provider.cover
129            if cover:
130                name = provider.__class__.__name__
131                print_d('Found local cover from {0}: {1}'.format(name, cover))
132                callback(True, cover)
133            else:
134                provider.connect('fetch-success', success)
135                provider.connect('fetch-failure', failure)
136                provider.fetch_cover()
137        if not cancellable or not cancellable.is_cancelled():
138            run()
139
140    def acquire_cover_sync(self, song, embedded=True, external=True):
141        """Gets *cached* cover synchronously.
142
143        As CoverSource fetching functionality is asynchronous it is only
144        possible to check for already fetched cover.
145        """
146
147        return self.acquire_cover_sync_many([song], embedded, external)
148
149    def acquire_cover_sync_many(self, songs, embedded=True, external=True):
150        """Same as acquire_cover_sync but returns a cover for multiple
151        images"""
152
153        for plugin in self.sources:
154            if not embedded and plugin.embedded:
155                continue
156            if not external and not plugin.embedded:
157                continue
158
159            groups = {}
160            for song in songs:
161                group = plugin.group_by(song) or ''
162                groups.setdefault(group, []).append(song)
163
164            # sort both groups and songs by key, so we always get
165            # the same result for the same set of songs
166            for key, group in sorted(groups.items()):
167                song = sorted(group, key=lambda s: s.key)[0]
168                cover = plugin(song).cover
169                if cover:
170                    return cover
171
172    def get_cover(self, song):
173        """Returns a cover file object for one song or None.
174
175        Compared to acquire_cover_sync() this respects the prefer_embedded
176        setting.
177        """
178
179        return self.get_cover_many([song])
180
181    def get_cover_many(self, songs):
182        """Returns a cover file object for many songs or None.
183
184        Returns the first found image for a group of songs.
185        It tries to return the same cover for the same set of songs.
186        """
187
188        return self.acquire_cover_sync_many(songs)
189
190    def get_pixbuf_many(self, songs, width, height):
191        """Returns a Pixbuf which fits into the boundary defined by width
192        and height or None.
193
194        Uses the thumbnail cache if possible.
195        """
196
197        fileobj = self.get_cover_many(songs)
198        if fileobj is None:
199            return
200
201        return get_thumbnail_from_file(fileobj, (width, height))
202
203    def get_pixbuf(self, song, width, height):
204        """see get_pixbuf_many()"""
205
206        return self.get_pixbuf_many([song], width, height)
207
208    def get_pixbuf_many_async(self, songs, width, height, cancel, callback):
209        """Async variant; callback gets called with a pixbuf or not called
210        in case of an error. cancel is a Gio.Cancellable.
211
212        The callback will be called in the main loop.
213        """
214
215        fileobj = self.get_cover_many(songs)
216        if fileobj is None:
217            return
218
219        call_async(get_thumbnail_from_file, cancel, callback,
220                   args=(fileobj, (width, height)))
221
222    def search_cover(self, cancellable, songs):
223        """Search for all the covers applicable to `songs` across all providers
224        Every successful image result emits a 'covers-found' signal
225        (unless cancelled)."""
226
227        sources = [source for source in self.sources if not source.embedded]
228        processed = {}
229        all_groups = {}
230        task = Task(_("Cover Art"), _("Querying album art providers"),
231                    stop=cancellable.cancel)
232
233        def finished(provider, success):
234            processed[provider] = success
235            total = self._total_groupings(all_groups)
236
237            frac = len(processed) / total
238            print_d("%s is finished: %d / %d"
239                    % (provider, len(processed), total))
240            task.update(frac)
241            if frac >= 1:
242                task.finish()
243                self.emit('searches-complete', processed)
244
245        def search_complete(provider, results):
246            name = provider.name
247            if not results:
248                print_d('No covers from {0}'.format(name))
249                finished(provider, False)
250                return
251            finished(provider, True)
252            if not (cancellable and cancellable.is_cancelled()):
253                covers = {CoverData(url=res['cover'], source=name,
254                                    dimensions=res.get('dimensions', None))
255                          for res in results}
256                self.emit('covers-found', provider, covers)
257            provider.disconnect_by_func(search_complete)
258
259        def failure(provider, result):
260            finished(provider, False)
261            name = provider.__class__.__name__
262            print_d('Failed to get cover from {name} ({msg})'.format(
263                name=name, msg=result))
264            provider.disconnect_by_func(failure)
265
266        def song_groups(songs, sources):
267            all_groups = {}
268            for plugin in sources:
269                groups = {}
270                for song in songs:
271                    group = plugin.group_by(song) or ''
272                    groups.setdefault(group, []).append(song)
273                all_groups[plugin] = groups
274            return all_groups
275
276        all_groups = song_groups(songs, sources)
277        print_d("Got %d plugin groupings" % self._total_groupings(all_groups))
278
279        for plugin, groups in all_groups.items():
280            print_d("Getting covers from %s" % plugin)
281            for key, group in sorted(groups.items()):
282                song = sorted(group, key=lambda s: s.key)[0]
283                artists = {s.comma('artist') for s in group}
284                if len(artists) > 1:
285                    print_d("%d artist groups in %s - probably a compilation. "
286                            "Using provider to search for compilation"
287                            % (len(artists), key))
288                    song = AudioFile(song)
289                    try:
290                        del song['artist']
291                    except KeyError:
292                        # Artist(s) from other grouped songs, never mind.
293                        pass
294                provider = plugin(song)
295                provider.connect('search-complete', search_complete)
296                provider.connect('fetch-failure', failure)
297                provider.search()
298        return all_groups
299
300    def _total_groupings(self, groups):
301        return sum(len(g) for g in groups.values())
302
303
304class CoverData(GObject.GObject):
305    """Structured data for results from cover searching"""
306    def __init__(self, url, source=None, dimensions=None):
307        super().__init__()
308        self.url = url
309        self.dimensions = dimensions
310        self.source = source
311
312    def __repr__(self):
313        return "CoverData<url=%s @ %s>" % (self.url, self.dimensions)
314