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