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