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