1# Copyright 2005 Eduardo Gonzalez <wm.eddie@gmail.com>, Niklas Janlert 2# 2006 Joe Wreschnig 3# 2008 Antonio Riva, Eduardo Gonzalez <wm.eddie@gmail.com>, 4# Anthony Bretaudeau <wxcover@users.sourceforge.net>, 5# 2010 Aymeric Mansoux <aymeric@goto10.org> 6# 2008-2013 Christoph Reiter 7# 2011-2017 Nick Boultbee 8# 2016 Mice Pápai 9# 10# This program is free software; you can redistribute it and/or modify 11# it under the terms of the GNU General Public License as published by 12# the Free Software Foundation; either version 2 of the License, or 13# (at your option) any later version. 14 15import json 16import os 17import re 18import time 19import threading 20import gzip 21from io import BytesIO 22from urllib.parse import urlencode 23 24from xml.dom import minidom 25 26from gi.repository import Gtk, Pango, GLib, Gdk, GdkPixbuf 27from quodlibet.pattern import ArbitraryExtensionFileFromPattern 28from quodlibet.pattern import Pattern 29from quodlibet.plugins import PluginConfigMixin 30from quodlibet.plugins.songshelpers import any_song, is_a_file 31from quodlibet.util import format_size, print_exc 32from quodlibet.util.dprint import print_d, print_w 33 34from quodlibet import _ 35from quodlibet import util, qltk, app 36from quodlibet.qltk.msg import ConfirmFileReplace 37from quodlibet.qltk.x import Paned, Align, Button 38from quodlibet.qltk.views import AllTreeView 39from quodlibet.qltk import Icons 40from quodlibet.qltk.image import scale, add_border_widget, \ 41 get_surface_for_pixbuf 42from quodlibet.plugins.songsmenu import SongsMenuPlugin 43from quodlibet.util.path import iscommand 44from quodlibet.util.urllib import urlopen, Request 45 46USER_AGENT = "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.13) " \ 47 "Gecko/20101210 Iceweasel/3.6.13 (like Firefox/3.6.13)" 48 49PLUGIN_CONFIG_SECTION = 'cover' 50CONFIG_ENG_PREFIX = 'engine_' 51 52SEARCH_PATTERN = Pattern( 53 '<albumartist|<albumartist>|<artist>> - <album|<album>|<title>>') 54 55REQUEST_LIMIT_MAX = 15 56 57 58def get_encoding_from_socket(socket): 59 content_type = socket.headers.get("Content-Type", "") 60 p = map(str.strip, map(str.lower, content_type.split(";"))) 61 enc = [t.split("=")[-1].strip() for t in p if t.startswith("charset")] 62 return (enc and enc[0]) or "utf-8" 63 64 65def get_url(url, post=None, get=None): 66 post_params = urlencode(post or {}) 67 get_params = urlencode(get or {}) 68 if get: 69 get_params = '?' + get_params 70 71 # add post, get data and headers 72 url = '%s%s' % (url, get_params) 73 if post_params: 74 request = Request(url, post_params) 75 else: 76 request = Request(url) 77 78 # for discogs 79 request.add_header('Accept-Encoding', 'gzip') 80 request.add_header('User-Agent', USER_AGENT) 81 82 url_sock = urlopen(request) 83 enc = get_encoding_from_socket(url_sock) 84 85 # unzip the response if needed 86 data = url_sock.read() 87 if url_sock.headers.get("content-encoding", "") == "gzip": 88 data = gzip.GzipFile(fileobj=BytesIO(data)).read() 89 url_sock.close() 90 content_type = url_sock.headers.get('Content-Type', '').split(';', 1)[0] 91 domain = re.compile(r'\w+://([^/]+)/').search(url).groups(0)[0] 92 print_d("Got %s data from %s" % (content_type, domain)) 93 return (data if content_type.startswith('image') 94 else data.decode(enc)) 95 96 97def get_encoding(url): 98 request = Request(url) 99 request.add_header('Accept-Encoding', 'gzip') 100 request.add_header('User-Agent', USER_AGENT) 101 url_sock = urlopen(request) 102 return get_encoding_from_socket(url_sock) 103 104 105class AmazonParser(object): 106 """A class for searching covers from Amazon""" 107 108 def __init__(self): 109 self.page_count = 1 110 self.covers = [] 111 self.limit = 0 112 113 def __parse_page(self, page, query): 114 """Gets all item tags and calls the item parsing function for each""" 115 116 # Amazon now requires that all requests be signed. 117 # I have built a webapp on AppEngine for this purpose. -- wm_eddie 118 # url = 'https://webservices.amazon.com/onca/xml' 119 url = 'https://qlwebservices.appspot.com/onca/xml' 120 121 parameters = { 122 'Service': 'AWSECommerceService', 123 'AWSAccessKeyId': '0RKH4ZH1JCFZHMND91G2', # Now Ignored. 124 'Operation': 'ItemSearch', 125 'ResponseGroup': 'Images,Small', 126 'SearchIndex': 'Music', 127 'Keywords': query, 128 'ItemPage': page, 129 # This specifies where the money goes and needed since 1.11.2011 130 # (What a good reason to break API..) 131 # ...so use the eff.org one: https://www.eff.org/helpout 132 'AssociateTag': 'electronicfro-20', 133 } 134 data = get_url(url, get=parameters) 135 dom = minidom.parseString(data) 136 137 pages = dom.getElementsByTagName('TotalPages') 138 if pages: 139 self.page_count = int(pages[0].firstChild.data) 140 141 items = dom.getElementsByTagName('Item') 142 print_d("Amazon: got %d search result(s)" % len(items)) 143 for item in items: 144 self.__parse_item(item) 145 if len(self.covers) >= self.limit: 146 break 147 148 def __parse_item(self, item): 149 """Extract all information and add the covers to the list.""" 150 151 large = item.getElementsByTagName('LargeImage') 152 small = item.getElementsByTagName('SmallImage') 153 title = item.getElementsByTagName('Title') 154 155 if large and small and title: 156 cover = {} 157 158 artist = item.getElementsByTagName('Artist') 159 creator = item.getElementsByTagName('Creator') 160 161 text = '' 162 if artist: 163 text = artist[0].firstChild.data 164 elif creator: 165 if len(creator) > 1: 166 text = ', '.join([i.firstChild.data for i in creator]) 167 else: 168 text = creator[0].firstChild.data 169 170 title_text = title[0].firstChild.data 171 172 if len(text) and len(title_text): 173 text += ' - ' 174 175 cover['name'] = text + title_text 176 177 url_tag = small[0].getElementsByTagName('URL')[0] 178 cover['thumbnail'] = url_tag.firstChild.data 179 180 url_tag = large[0].getElementsByTagName('URL')[0] 181 cover['cover'] = url_tag.firstChild.data 182 183 #Since we don't know the size, use the one from the HTML header. 184 cover['size'] = get_size_of_url(cover['cover']) 185 186 h_tag = large[0].getElementsByTagName('Height')[0] 187 height = h_tag.firstChild.data 188 189 w_tag = large[0].getElementsByTagName('Width')[0] 190 width = w_tag.firstChild.data 191 192 cover['resolution'] = '%s x %s px' % (width, height) 193 194 cover['source'] = 'https://www.amazon.com' 195 196 self.covers.append(cover) 197 198 def start(self, query, limit=5): 199 """Start the search and returns the covers""" 200 201 self.page_count = 0 202 self.covers = [] 203 self.limit = limit 204 page = 1 205 206 while len(self.covers) < limit: 207 self.__parse_page(page, query) 208 if page >= self.page_count: 209 break 210 page += 1 211 212 return self.covers 213 214 215class DiscogsParser(object): 216 """A class for searching covers from Amazon""" 217 218 def __init__(self): 219 self.page_count = 0 220 self.covers = [] 221 self.limit = 0 222 self.creds = {'key': 'aWfZGjHQvkMcreUECGAp', 223 'secret': 'VlORkklpdvAwJMwxUjNNSgqicjuizJAl'} 224 225 def __parse_page(self, page, query): 226 """Gets all item tags and calls the item parsing function for each""" 227 228 url = 'https://api.discogs.com/database/search' 229 230 parameters = { 231 'type': 'release', 232 'q': query, 233 'page': page, 234 # Assume that not all results are useful 235 'per_page': self.limit * 2, 236 } 237 238 parameters.update(self.creds) 239 data = get_url(url, get=parameters) 240 json_dict = json.loads(data) 241 242 # TODO: rate limiting 243 244 pages = json_dict.get('pagination', {}).get('pages', 0) 245 if not pages: 246 return 247 self.page_count = int(pages) 248 249 items = json_dict.get('results', {}) 250 print_d("Discogs: got %d search result(s)" % len(items)) 251 for item in items: 252 self.__parse_item(item) 253 if len(self.covers) >= self.limit: 254 break 255 256 def __parse_item(self, item): 257 """Extract all information and add the covers to the list.""" 258 259 thumbnail = item.get('thumb', '') 260 if thumbnail is None: 261 print_d("Release doesn't have a cover") 262 return 263 264 res_url = item.get('resource_url', '') 265 data = get_url(res_url, get=self.creds) 266 json_dict = json.loads(data) 267 268 images = json_dict.get('images', []) 269 270 for i, image in enumerate(images): 271 272 type = image.get('type', '') 273 if type != 'primary': 274 continue 275 276 uri = image.get('uri', '') 277 cover = {'source': 'https://www.discogs.com', 278 'name': item.get('title', ''), 279 'thumbnail': image.get('uri150', thumbnail), 280 'cover': uri, 281 'size': get_size_of_url(uri)} 282 283 width = image.get('width', 0) 284 height = image.get('height', 0) 285 cover['resolution'] = '%s x %s px' % (width, height) 286 287 self.covers.append(cover) 288 if len(self.covers) >= self.limit: 289 break 290 291 def start(self, query, limit=3): 292 """Start the search and returns the covers""" 293 294 self.page_count = 0 295 self.covers = [] 296 self.limit = limit 297 page = 1 298 while len(self.covers) < limit: 299 self.__parse_page(page, query) 300 if page >= self.page_count: 301 break 302 page += 1 303 304 return self.covers 305 306 307class CoverArea(Gtk.VBox, PluginConfigMixin): 308 """The image display and saving part.""" 309 310 CONFIG_SECTION = PLUGIN_CONFIG_SECTION 311 312 def __init__(self, parent, song): 313 super(CoverArea, self).__init__() 314 self.song = song 315 316 self.dirname = song("~dirname") 317 self.main_win = parent 318 319 self.data_cache = [] 320 self.current_data = None 321 self.current_pixbuf = None 322 323 self.image = Gtk.Image() 324 self.button = Button(_("_Save"), Icons.DOCUMENT_SAVE_AS) 325 self.button.set_sensitive(False) 326 self.button.connect('clicked', self.__save) 327 328 close_button = Button(_("_Close"), Icons.WINDOW_CLOSE) 329 close_button.connect('clicked', lambda x: self.main_win.destroy()) 330 331 self.window_fit = self.ConfigCheckButton(_('Fit image to _window'), 332 'fit', True) 333 self.window_fit.connect('toggled', self.__scale_pixbuf) 334 335 self.name_combo = Gtk.ComboBoxText() 336 self.name_combo.set_tooltip_text( 337 _("See '[plugins] cover_filenames' config entry " + 338 "for image filename strings")) 339 340 self.cmd = qltk.entry.ValidatingEntry(iscommand) 341 342 # Both labels 343 label_open = Gtk.Label(label=_('_Program:')) 344 label_open.set_use_underline(True) 345 label_open.set_mnemonic_widget(self.cmd) 346 label_open.set_justify(Gtk.Justification.LEFT) 347 348 self.open_check = self.ConfigCheckButton(_('_Edit image after saving'), 349 'edit', False) 350 label_name = Gtk.Label(label=_('File_name:'), use_underline=True) 351 label_name.set_use_underline(True) 352 label_name.set_mnemonic_widget(self.name_combo) 353 label_name.set_justify(Gtk.Justification.LEFT) 354 355 self.cmd.set_text(self.config_get('edit_cmd', 'gimp')) 356 357 # populate the filename combo box 358 fn_list = self.config_get_stringlist('filenames', 359 ["cover.jpg", "folder.jpg", ".folder.jpg"]) 360 # Issue 374 - add dynamic file names 361 fn_dynlist = [] 362 artist = song("artist") 363 alartist = song("albumartist") 364 album = song("album") 365 labelid = song("labelid") 366 if album: 367 fn_dynlist.append("<album>.jpg") 368 if alartist: 369 fn_dynlist.append("<albumartist> - <album>.jpg") 370 else: 371 fn_dynlist.append("<artist> - <album>.jpg") 372 else: 373 print_w(u"No album for \"%s\". Could be difficult " 374 u"finding art…" % song("~filename")) 375 title = song("title") 376 if title and artist: 377 fn_dynlist.append("<artist> - <title>.jpg") 378 if labelid: 379 fn_dynlist.append("<labelid>.jpg") 380 # merge unique 381 fn_list.extend(s for s in fn_dynlist if s not in fn_list) 382 383 set_fn = self.config_get('filename', fn_list[0]) 384 385 for i, fn in enumerate(fn_list): 386 self.name_combo.append_text(fn) 387 if fn == set_fn: 388 self.name_combo.set_active(i) 389 390 if self.name_combo.get_active() < 0: 391 self.name_combo.set_active(0) 392 self.config_set('filename', self.name_combo.get_active_text()) 393 394 table = Gtk.Table(n_rows=2, n_columns=2, homogeneous=False) 395 table.props.expand = False 396 table.set_row_spacing(0, 5) 397 table.set_row_spacing(1, 5) 398 table.set_col_spacing(0, 5) 399 table.set_col_spacing(1, 5) 400 401 table.attach(label_open, 0, 1, 0, 1) 402 table.attach(label_name, 0, 1, 1, 2) 403 404 table.attach(self.cmd, 1, 2, 0, 1) 405 table.attach(self.name_combo, 1, 2, 1, 2) 406 407 self.scrolled = Gtk.ScrolledWindow() 408 self.scrolled.add_with_viewport(self.image) 409 self.scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, 410 Gtk.PolicyType.AUTOMATIC) 411 412 bbox = Gtk.HButtonBox() 413 bbox.set_spacing(6) 414 bbox.set_layout(Gtk.ButtonBoxStyle.END) 415 bbox.pack_start(self.button, True, True, 0) 416 bbox.pack_start(close_button, True, True, 0) 417 418 bb_align = Align(valign=Gtk.Align.END, right=6) 419 bb_align.add(bbox) 420 421 main_hbox = Gtk.HBox() 422 main_hbox.pack_start(table, False, True, 6) 423 main_hbox.pack_start(bb_align, True, True, 0) 424 425 top_hbox = Gtk.HBox() 426 top_hbox.pack_start(self.open_check, True, True, 0) 427 top_hbox.pack_start(self.window_fit, False, True, 0) 428 429 main_vbox = Gtk.VBox() 430 main_vbox.pack_start(top_hbox, True, True, 2) 431 main_vbox.pack_start(main_hbox, True, True, 0) 432 433 self.pack_start(self.scrolled, True, True, 0) 434 self.pack_start(main_vbox, False, True, 5) 435 436 # 5 MB image cache size 437 self.max_cache_size = 1024 * 1024 * 5 438 439 # For managing fast selection switches of covers.. 440 self.stop_loading = False 441 self.loading = False 442 self.current_job = 0 443 444 self.connect('destroy', self.__save_config) 445 446 def __save(self, *data): 447 """Save the cover and spawn the program to edit it if selected""" 448 449 save_format = self.name_combo.get_active_text() 450 # Allow use of patterns in creating cover filenames 451 pattern = ArbitraryExtensionFileFromPattern(save_format) 452 filename = pattern.format(self.song) 453 print_d("Using '%s' as filename based on %s" % (filename, save_format)) 454 file_path = os.path.join(self.dirname, filename) 455 456 if os.path.exists(file_path): 457 resp = ConfirmFileReplace(self, file_path).run() 458 if resp != ConfirmFileReplace.RESPONSE_REPLACE: 459 return 460 461 try: 462 f = open(file_path, 'wb') 463 f.write(self.current_data) 464 f.close() 465 except IOError: 466 qltk.ErrorMessage(None, _('Saving failed'), 467 _('Unable to save "%s".') % file_path).run() 468 else: 469 if self.open_check.get_active(): 470 try: 471 util.spawn([self.cmd.get_text(), file_path]) 472 except: 473 pass 474 475 app.cover_manager.cover_changed([self.song._song]) 476 477 self.main_win.destroy() 478 479 def __save_config(self, widget): 480 self.config_set('edit_cmd', self.cmd.get_text()) 481 self.config_set('filename', self.name_combo.get_active_text()) 482 483 def __update(self, loader, *data): 484 """Update the picture while it's loading""" 485 486 if self.stop_loading: 487 return 488 pixbuf = loader.get_pixbuf() 489 490 def idle_set(): 491 if pixbuf is not None: 492 surface = get_surface_for_pixbuf(self, pixbuf) 493 self.image.set_from_surface(surface) 494 495 GLib.idle_add(idle_set) 496 497 def __scale_pixbuf(self, *data): 498 if not self.current_pixbuf: 499 return 500 pixbuf = self.current_pixbuf 501 502 if self.window_fit.get_active(): 503 alloc = self.scrolled.get_allocation() 504 width = alloc.width 505 height = alloc.height 506 scale_factor = self.get_scale_factor() 507 boundary = (width * scale_factor, height * scale_factor) 508 pixbuf = scale(pixbuf, boundary, scale_up=False) 509 510 surface = get_surface_for_pixbuf(self, pixbuf) 511 self.image.set_from_surface(surface) 512 513 def __close(self, loader, *data): 514 if self.stop_loading: 515 return 516 self.current_pixbuf = loader.get_pixbuf() 517 GLib.idle_add(self.__scale_pixbuf) 518 519 def set_cover(self, url): 520 thr = threading.Thread(target=self.__set_async, args=(url,)) 521 thr.setDaemon(True) 522 thr.start() 523 524 def __set_async(self, url): 525 """Manages various things: 526 Fast switching of covers (aborting old HTTP requests), 527 The image cache, etc.""" 528 529 self.current_job += 1 530 job = self.current_job 531 532 self.stop_loading = True 533 while self.loading: 534 time.sleep(0.05) 535 self.stop_loading = False 536 537 if job != self.current_job: 538 return 539 540 self.loading = True 541 542 GLib.idle_add(self.button.set_sensitive, False) 543 self.current_pixbuf = None 544 545 pbloader = GdkPixbuf.PixbufLoader() 546 pbloader.connect('closed', self.__close) 547 548 # Look for cached images 549 raw_data = None 550 for entry in self.data_cache: 551 if entry[0] == url: 552 raw_data = entry[1] 553 break 554 555 if not raw_data: 556 pbloader.connect('area-updated', self.__update) 557 558 data_store = BytesIO() 559 560 try: 561 request = Request(url) 562 request.add_header('User-Agent', USER_AGENT) 563 url_sock = urlopen(request) 564 except EnvironmentError: 565 print_w(_("[albumart] HTTP Error: %s") % url) 566 else: 567 while not self.stop_loading: 568 tmp = url_sock.read(1024 * 10) 569 if not tmp: 570 break 571 pbloader.write(tmp) 572 data_store.write(tmp) 573 574 url_sock.close() 575 576 if not self.stop_loading: 577 raw_data = data_store.getvalue() 578 579 self.data_cache.insert(0, (url, raw_data)) 580 581 while 1: 582 cache_sizes = [len(data[1]) for data in 583 self.data_cache] 584 if sum(cache_sizes) > self.max_cache_size: 585 del self.data_cache[-1] 586 else: 587 break 588 589 data_store.close() 590 else: 591 # Sleep for fast switching of cached images 592 time.sleep(0.05) 593 if not self.stop_loading: 594 pbloader.write(raw_data) 595 596 try: 597 pbloader.close() 598 except GLib.GError: 599 pass 600 601 self.current_data = raw_data 602 603 if not self.stop_loading: 604 GLib.idle_add(self.button.set_sensitive, True) 605 606 self.loading = False 607 608 609class AlbumArtWindow(qltk.Window, PluginConfigMixin): 610 """The main window including the search list""" 611 612 CONFIG_SECTION = PLUGIN_CONFIG_SECTION 613 THUMB_SIZE = 50 614 615 def __init__(self, songs): 616 super(AlbumArtWindow, self).__init__() 617 618 self.image_cache = [] 619 self.image_cache_size = 10 620 self.search_lock = False 621 622 self.set_title(_('Album Art Downloader')) 623 self.set_icon_name(Icons.EDIT_FIND) 624 self.set_default_size(800, 550) 625 626 image = CoverArea(self, songs[0]) 627 628 self.liststore = Gtk.ListStore(object, object) 629 self.treeview = treeview = AllTreeView(model=self.liststore) 630 self.treeview.set_headers_visible(False) 631 self.treeview.set_rules_hint(True) 632 633 targets = [("text/uri-list", 0, 0)] 634 targets = [Gtk.TargetEntry.new(*t) for t in targets] 635 636 treeview.drag_source_set( 637 Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY) 638 639 treeselection = self.treeview.get_selection() 640 treeselection.set_mode(Gtk.SelectionMode.SINGLE) 641 treeselection.connect('changed', self.__select_callback, image) 642 643 self.treeview.connect("drag-data-get", 644 self.__drag_data_get, treeselection) 645 646 rend_pix = Gtk.CellRendererPixbuf() 647 img_col = Gtk.TreeViewColumn('Thumb') 648 img_col.pack_start(rend_pix, False) 649 650 def cell_data_pb(column, cell, model, iter_, *args): 651 surface = model[iter_][0] 652 cell.set_property("surface", surface) 653 654 img_col.set_cell_data_func(rend_pix, cell_data_pb, None) 655 treeview.append_column(img_col) 656 657 rend_pix.set_property('xpad', 2) 658 rend_pix.set_property('ypad', 2) 659 border_width = self.get_scale_factor() * 2 660 rend_pix.set_property('width', self.THUMB_SIZE + 4 + border_width) 661 rend_pix.set_property('height', self.THUMB_SIZE + 4 + border_width) 662 663 def escape_data(data): 664 for rep in ('\n', '\t', '\r', '\v'): 665 data = data.replace(rep, ' ') 666 return util.escape(' '.join(data.split())) 667 668 def cell_data(column, cell, model, iter, data): 669 cover = model[iter][1] 670 671 esc = escape_data 672 673 txt = '<b><i>%s</i></b>' % esc(cover['name']) 674 txt += "\n<small>%s</small>" % ( 675 _('from %(source)s') % { 676 "source": util.italic(esc(cover['source']))}) 677 if 'resolution' in cover: 678 txt += "\n" + _('Resolution: %s') % util.italic( 679 esc(cover['resolution'])) 680 if 'size' in cover: 681 txt += "\n" + _('Size: %s') % util.italic(esc(cover['size'])) 682 683 cell.markup = txt 684 cell.set_property('markup', cell.markup) 685 686 rend = Gtk.CellRendererText() 687 rend.set_property('ellipsize', Pango.EllipsizeMode.END) 688 info_col = Gtk.TreeViewColumn('Info', rend) 689 info_col.set_cell_data_func(rend, cell_data) 690 691 treeview.append_column(info_col) 692 693 sw_list = Gtk.ScrolledWindow() 694 sw_list.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 695 sw_list.set_shadow_type(Gtk.ShadowType.IN) 696 sw_list.add(treeview) 697 698 search_labelraw = Gtk.Label('raw') 699 search_labelraw.set_alignment(xalign=1.0, yalign=0.5) 700 self.search_fieldraw = Gtk.Entry() 701 self.search_fieldraw.connect('activate', self.start_search) 702 self.search_fieldraw.connect('changed', self.__searchfieldchanged) 703 search_labelclean = Gtk.Label('clean') 704 search_labelclean.set_alignment(xalign=1.0, yalign=0.5) 705 self.search_fieldclean = Gtk.Label() 706 self.search_fieldclean.set_can_focus(False) 707 self.search_fieldclean.set_alignment(xalign=0.0, yalign=0.5) 708 709 self.search_radioraw = Gtk.RadioButton(group=None, label=None) 710 self.search_radioraw.connect("toggled", self.__searchtypetoggled, 711 "raw") 712 self.search_radioclean = Gtk.RadioButton(group=self.search_radioraw, 713 label=None) 714 self.search_radioclean.connect("toggled", self.__searchtypetoggled, 715 "clean") 716 #note: set_active(False) appears to have no effect 717 #self.search_radioraw.set_active( 718 # self.config_get_bool('searchraw', False)) 719 if self.config_get_bool('searchraw', False): 720 self.search_radioraw.set_active(True) 721 else: 722 self.search_radioclean.set_active(True) 723 724 search_labelresultsmax = Gtk.Label('limit') 725 search_labelresultsmax.set_alignment(xalign=1.0, yalign=0.5) 726 search_labelresultsmax.set_tooltip_text( 727 _("Per engine 'at best' results limit")) 728 search_adjresultsmax = Gtk.Adjustment( 729 value=int(self.config_get("resultsmax", 3)), lower=1, 730 upper=REQUEST_LIMIT_MAX, step_incr=1, 731 page_incr=0, page_size=0) 732 self.search_spinresultsmax = Gtk.SpinButton( 733 adjustment=search_adjresultsmax, climb_rate=0.2, digits=0) 734 self.search_spinresultsmax.set_alignment(xalign=0.5) 735 self.search_spinresultsmax.set_can_focus(False) 736 737 self.search_button = Button(_("_Search"), Icons.EDIT_FIND) 738 self.search_button.connect('clicked', self.start_search) 739 search_button_box = Gtk.Alignment() 740 search_button_box.set(1, 0, 0, 0) 741 search_button_box.add(self.search_button) 742 743 search_table = Gtk.Table(rows=3, columns=4, homogeneous=False) 744 search_table.attach(search_labelraw, 0, 1, 0, 1, 745 xoptions=Gtk.AttachOptions.FILL, xpadding=6) 746 search_table.attach(self.search_radioraw, 1, 2, 0, 1, 747 xoptions=0, xpadding=0) 748 search_table.attach(self.search_fieldraw, 2, 4, 0, 1) 749 search_table.attach(search_labelclean, 0, 1, 1, 2, 750 xoptions=Gtk.AttachOptions.FILL, xpadding=6) 751 search_table.attach(self.search_radioclean, 1, 2, 1, 2, 752 xoptions=0, xpadding=0) 753 search_table.attach(self.search_fieldclean, 2, 4, 1, 2, xpadding=4) 754 search_table.attach(search_labelresultsmax, 0, 2, 2, 3, 755 xoptions=Gtk.AttachOptions.FILL, xpadding=6) 756 search_table.attach(self.search_spinresultsmax, 2, 3, 2, 3, 757 xoptions=Gtk.AttachOptions.FILL, xpadding=0) 758 search_table.attach(search_button_box, 3, 4, 2, 3) 759 760 widget_space = 5 761 762 self.progress = Gtk.ProgressBar() 763 764 left_vbox = Gtk.VBox(spacing=widget_space) 765 left_vbox.pack_start(search_table, False, True, 0) 766 left_vbox.pack_start(sw_list, True, True, 0) 767 768 hpaned = Paned() 769 hpaned.set_border_width(widget_space) 770 hpaned.pack1(left_vbox, shrink=False) 771 hpaned.pack2(image, shrink=False) 772 hpaned.set_position(275) 773 774 self.add(hpaned) 775 776 self.show_all() 777 778 left_vbox.pack_start(self.progress, False, True, 0) 779 780 self.connect('destroy', self.__save_config) 781 782 song = songs[0] 783 text = SEARCH_PATTERN.format(song) 784 self.set_text(text) 785 self.start_search() 786 787 def __save_config(self, widget): 788 self.config_set('searchraw', self.search_radioraw.get_active()) 789 self.config_set('resultsmax', 790 self.search_spinresultsmax.get_value_as_int()) 791 792 def __drag_data_get(self, view, ctx, sel, tid, etime, treeselection): 793 model, iter = treeselection.get_selected() 794 if not iter: 795 return 796 cover = model.get_value(iter, 1) 797 sel.set_uris([cover['cover']]) 798 799 def __searchfieldchanged(self, *data): 800 search = data[0].get_text() 801 clean = cleanup_query(search, ' ') 802 self.search_fieldclean.set_text('<b>' + clean + '</b>') 803 self.search_fieldclean.set_use_markup(True) 804 805 def __searchtypetoggled(self, *data): 806 self.config_set('searchraw', self.search_radioraw.get_active()) 807 808 def start_search(self, *data): 809 """Start the search using the text from the text entry""" 810 811 text = self.search_fieldraw.get_text() 812 if not text or self.search_lock: 813 return 814 815 self.search_lock = True 816 self.search_button.set_sensitive(False) 817 818 self.progress.set_fraction(0) 819 self.progress.set_text(_(u'Searching…')) 820 self.progress.show() 821 822 self.liststore.clear() 823 824 self.search = search = CoverSearch(self.__search_callback) 825 826 for eng in ENGINES: 827 if self.config_get_bool( 828 CONFIG_ENG_PREFIX + eng['config_id'], True): 829 search.add_engine(eng['class'], eng['replace']) 830 831 raw = self.search_radioraw.get_active() 832 limit = self.search_spinresultsmax.get_value_as_int() 833 search.start(text, raw, limit) 834 835 # Focus the list 836 self.treeview.grab_focus() 837 838 self.connect("destroy", self.__destroy) 839 840 def __destroy(self, *args): 841 self.search.stop() 842 843 def set_text(self, text): 844 """set the text and move the cursor to the end""" 845 846 self.search_fieldraw.set_text(text) 847 self.search_fieldraw.emit('move-cursor', Gtk.MovementStep.BUFFER_ENDS, 848 0, False) 849 850 def __select_callback(self, selection, image): 851 model, iter = selection.get_selected() 852 if not iter: 853 return 854 cover = model.get_value(iter, 1) 855 image.set_cover(cover['cover']) 856 857 def __add_cover_to_list(self, cover): 858 try: 859 pbloader = GdkPixbuf.PixbufLoader() 860 pbloader.write(get_url(cover['thumbnail'])) 861 pbloader.close() 862 863 scale_factor = self.get_scale_factor() 864 size = self.THUMB_SIZE * scale_factor - scale_factor * 2 865 pixbuf = pbloader.get_pixbuf().scale_simple(size, size, 866 GdkPixbuf.InterpType.BILINEAR) 867 pixbuf = add_border_widget(pixbuf, self) 868 surface = get_surface_for_pixbuf(self, pixbuf) 869 except (GLib.GError, IOError): 870 pass 871 else: 872 def append(data): 873 self.liststore.append(data) 874 GLib.idle_add(append, [surface, cover]) 875 876 def __search_callback(self, covers, progress): 877 for cover in covers: 878 self.__add_cover_to_list(cover) 879 880 if self.progress.get_fraction() < progress: 881 self.progress.set_fraction(progress) 882 883 if progress >= 1: 884 self.progress.set_text(_('Done')) 885 GLib.timeout_add(700, self.progress.hide) 886 self.search_button.set_sensitive(True) 887 self.search_lock = False 888 889 890class CoverSearch(object): 891 """Class for glueing the search engines together. No UI stuff.""" 892 893 def __init__(self, callback): 894 self.engine_list = [] 895 self._stop = False 896 897 def wrap(*args, **kwargs): 898 if not self._stop: 899 return callback(*args, **kwargs) 900 901 self.callback = wrap 902 self.finished = 0 903 904 def add_engine(self, engine, query_replace): 905 """Adds a new search engine, query_replace is the string with which 906 all special characters get replaced""" 907 908 self.engine_list.append((engine, query_replace)) 909 910 def stop(self): 911 """After stop the progress callback will no longer be called""" 912 913 self._stop = True 914 915 def start(self, query, raw, limit): 916 """Start search. The callback function will be called after each of 917 the search engines has finished.""" 918 919 for engine, replace in self.engine_list: 920 thr = threading.Thread(target=self.__search_thread, 921 args=(engine, query, replace, raw, limit)) 922 thr.setDaemon(True) 923 thr.start() 924 925 #tell the other side that we are finished if there is nothing to do. 926 if not len(self.engine_list): 927 GLib.idle_add(self.callback, [], 1) 928 929 def __search_thread(self, engine, query, replace, raw, limit): 930 """Creates searching threads which call the callback function after 931 they are finished""" 932 933 search = query if raw else cleanup_query(query, replace) 934 935 print_d("[AlbumArt] running search %r on engine %s" % 936 (search, engine.__name__)) 937 result = [] 938 try: 939 result = engine().start(search, limit) 940 except Exception: 941 print_w("[AlbumArt] %s: %r" % (engine.__name__, query)) 942 print_exc() 943 944 self.finished += 1 945 #progress is between 0..1 946 progress = float(self.finished) / len(self.engine_list) 947 GLib.idle_add(self.callback, result, progress) 948 949 950def cleanup_query(query, replace): 951 """split up at '-', remove some chars, only keep the longest words.. 952 more false positives but much better results""" 953 954 query = query.lower() 955 if query.startswith("the "): 956 query = query[4:] 957 958 split = query.split('-') 959 replace_str = ('+', '&', ',', '.', '!', '´', 960 '\'', ':', ' and ', '(', ')') 961 new_query = '' 962 for part in split: 963 for stri in replace_str: 964 part = part.replace(stri, replace) 965 966 p_split = part.split() 967 p_split.sort(key=len, reverse=True) 968 end = max(int(len(p_split) / 4), max(4 - len(p_split), 2)) 969 p_split = p_split[:end] 970 971 new_query += ' '.join(p_split) + ' ' 972 973 return new_query.rstrip() 974 975 976def get_size_of_url(url): 977 request = Request(url) 978 request.add_header('Accept-Encoding', 'gzip') 979 request.add_header('User-Agent', USER_AGENT) 980 url_sock = urlopen(request) 981 size = url_sock.headers.get('content-length') 982 url_sock.close() 983 return format_size(int(size)) if size else '' 984 985 986ENGINES = [ 987 { 988 'class': AmazonParser, 989 'url': 'https://www.amazon.com/', 990 'replace': ' ', 991 'config_id': 'amazon', 992 }, 993 { 994 'class': DiscogsParser, 995 'url': 'https://www.discogs.com/', 996 'replace': ' ', 997 'config_id': 'discogs', 998 }, 999] 1000 1001 1002class DownloadAlbumArt(SongsMenuPlugin, PluginConfigMixin): 1003 """Download and save album (cover) art from a variety of sources""" 1004 1005 PLUGIN_ID = 'Download Album Art' 1006 PLUGIN_NAME = _('Download Album Art') 1007 PLUGIN_DESC = _('Downloads album covers from various websites.') 1008 PLUGIN_ICON = Icons.INSERT_IMAGE 1009 CONFIG_SECTION = PLUGIN_CONFIG_SECTION 1010 REQUIRES_ACTION = True 1011 1012 plugin_handles = any_song(is_a_file) 1013 1014 @classmethod 1015 def PluginPreferences(cls, window): 1016 table = Gtk.Table(n_rows=len(ENGINES), n_columns=2) 1017 table.props.expand = False 1018 table.set_col_spacings(6) 1019 table.set_row_spacings(6) 1020 frame = qltk.Frame(_("Sources"), child=table) 1021 1022 for i, eng in enumerate(sorted(ENGINES, key=lambda x: x["url"])): 1023 check = cls.ConfigCheckButton( 1024 eng['config_id'].title(), 1025 CONFIG_ENG_PREFIX + eng['config_id'], 1026 True) 1027 table.attach(check, 0, 1, i, i + 1) 1028 1029 button = Gtk.Button(label=eng['url']) 1030 button.connect('clicked', lambda s: util.website(s.get_label())) 1031 table.attach(button, 1, 2, i, i + 1, 1032 xoptions=Gtk.AttachOptions.FILL | 1033 Gtk.AttachOptions.SHRINK) 1034 return frame 1035 1036 def plugin_album(self, songs): 1037 return AlbumArtWindow(songs) 1038