diff --git a/picard/coverart/providers/caa.py b/picard/coverart/providers/caa.py index 20ad2bf6d..2fed071d8 100644 --- a/picard/coverart/providers/caa.py +++ b/picard/coverart/providers/caa.py @@ -26,6 +26,7 @@ from collections import ( OrderedDict, namedtuple, ) +from functools import partial from PyQt5 import ( QtCore, @@ -72,6 +73,9 @@ _CAA_THUMBNAIL_SIZE_MAP = OrderedDict([ _CAA_IMAGE_SIZE_DEFAULT = 500 +_CAA_IMAGE_TYPE_DEFAULT_INCLUDE = ['front',] +_CAA_IMAGE_TYPE_DEFAULT_EXCLUDE = ['raw/unedited', 'watermark',] + def caa_url_fallback_list(desired_size, thumbnails): """List of thumbnail urls equal or smaller than size, in size decreasing order @@ -94,34 +98,193 @@ def caa_url_fallback_list(desired_size, thumbnails): return urls -class CAATypesSelectorDialog(QtWidgets.QDialog): - _columns = 4 +class ArrowButton(QtWidgets.QPushButton): + """Standard arrow button for CAA image type selection dialog. - def __init__(self, parent=None, types=None): - if types is None: - types = [] + Keyword Arguments: + label {string} -- Label to display on the button + command {command} -- Command to execute when the button is clicked (default: {None}) + parent {[type]} -- Parent of the QPushButton object being created (default: {None}) + """ + + ARROW_BUTTON_WIDTH = 35 + ARROW_BUTTON_HEIGHT = 20 + + def __init__(self, label, command=None, parent=None): + super().__init__(label, parent=parent) + if command is not None: + self.clicked.connect(command) + self.setFixedSize(QtCore.QSize(self.ARROW_BUTTON_WIDTH, self.ARROW_BUTTON_HEIGHT)) + + +class ArrowsColumn(QtWidgets.QWidget): + """Standard arrow buttons column for CAA image type selection dialog. + + Keyword Arguments: + selection_list {ListBox} -- ListBox of selected items associated with this arrow column + ignore_list {ListBox} -- ListBox of unselected items associated with this arrow column + callback {command} -- Command to execute after items are moved between lists (default: {None}) + reverse {bool} -- Determines whether the arrow directions should be reversed (default: {False}) + parent {[type]} -- Parent of the QWidget object being created (default: {None}) + """ + + def __init__(self, selection_list, ignore_list, callback=None, reverse=False, parent=None): + super().__init__(parent=parent) + self.selection_list = selection_list + self.ignore_list = ignore_list + self.callback = callback + spacer_item = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + arrows_layout = QtWidgets.QVBoxLayout() + arrows_layout.addItem(QtWidgets.QSpacerItem(spacer_item)) + self.button_add = ArrowButton('>' if reverse else '<', self.move_from_ignore) + arrows_layout.addWidget(self.button_add) + self.button_add_all = ArrowButton('>>' if reverse else '<<', self.move_all_from_ignore) + arrows_layout.addWidget(self.button_add_all) + self.button_remove = ArrowButton('<' if reverse else '>', self.move_to_ignore) + arrows_layout.addWidget(self.button_remove) + self.button_remove_all = ArrowButton('<<' if reverse else '>>', self.move_all_to_ignore) + arrows_layout.addWidget(self.button_remove_all) + arrows_layout.addItem(QtWidgets.QSpacerItem(spacer_item)) + self.setLayout(arrows_layout) + + def move_from_ignore(self): + self.ignore_list.move_selected_items(self.selection_list, callback=self.callback) + + def move_all_from_ignore(self): + self.ignore_list.move_all_items(self.selection_list, callback=self.callback) + + def move_to_ignore(self): + self.selection_list.move_selected_items(self.ignore_list, callback=self.callback) + + def move_all_to_ignore(self): + self.selection_list.move_all_items(self.ignore_list, callback=self.callback) + + +class ListBox(QtWidgets.QListWidget): + """Standard list box for CAA image type selection dialog. + + Keyword Arguments: + parent {[type]} -- Parent of the QListWidget object being created (default: {None}) + """ + + LISTBOX_WIDTH = 150 + LISTBOX_HEIGHT = 250 + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setFixedSize(QtCore.QSize(self.LISTBOX_WIDTH, self.LISTBOX_HEIGHT)) + self.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.setSortingEnabled(True) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def move_item(self, item, target_list): + """Move the specified item to another listbox.""" + self.takeItem(self.row(item)) + target_list.addItem(item) + + def move_selected_items(self, target_list, callback=None): + """Move the selected item to another listbox.""" + for item in self.selectedItems(): + self.move_item(item, target_list) + if callback: + callback() + + def move_all_items(self, target_list, callback=None): + """Move all items to another listbox.""" + while self.count(): + self.move_item(self.item(0), target_list) + if callback: + callback() + + def all_items_data(self, role=QtCore.Qt.UserRole): + for index in range(self.count()): + yield self.item(index).data(role) + + +class CAATypesSelectorDialog(QtWidgets.QDialog): + """Display dialog box to select the CAA image types to include and exclude from download and use. + + Keyword Arguments: + parent {[type]} -- Parent of the QDialog object being created (default: {None}) + types_include {[string]} -- List of CAA image types to include (default: {None}) + types_exclude {[string]} -- List of CAA image types to exclude (default: {None}) + """ + + def __init__(self, parent=None, types_include=None, types_exclude=None): super().__init__(parent) + if types_include is None: + types_include = [] + if types_exclude is None: + types_exclude = [] self.setWindowTitle(_("Cover art types")) - self._items = {} self.layout = QtWidgets.QVBoxLayout(self) + # Create list boxes for dialog + self.list_include = ListBox() + self.list_exclude = ListBox() + self.list_ignore = ListBox() + + # Populate list boxes from current settings + self.fill_lists(types_include, types_exclude) + + # Set triggers when the lists receive the current focus + self.list_include.clicked.connect(partial(self.clear_focus, [self.list_ignore, self.list_exclude,])) + self.list_exclude.clicked.connect(partial(self.clear_focus, [self.list_ignore, self.list_include,])) + self.list_ignore.clicked.connect(partial(self.clear_focus, [self.list_include, self.list_exclude,])) + + # Add instructions to the dialog box + instructions = QtWidgets.QLabel() + instructions.setText(_("Please select the contents of the image type 'Include' and 'Exclude' lists.")) + instructions.setWordWrap(True) + instructions.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + self.layout.addWidget(instructions) + grid = QtWidgets.QWidget() gridlayout = QtWidgets.QGridLayout() grid.setLayout(gridlayout) - for index, caa_type in enumerate(CAA_TYPES): - row = index // self._columns - column = index % self._columns - name = caa_type["name"] - text = translate_caa_type(name) - item = QtWidgets.QCheckBox(text) - item.setChecked(name in types) - self._items[item] = caa_type - gridlayout.addWidget(item, row, column) + self.arrows_include = ArrowsColumn( + self.list_include, + self.list_ignore, + callback=self.set_buttons_enabled_state, + ) + + self.arrows_exclude = ArrowsColumn( + self.list_exclude, + self.list_ignore, + callback=self.set_buttons_enabled_state, + reverse=True + ) + + def add_widget(row=0, column=0, widget=None): + gridlayout.addWidget(widget, row, column) + + add_widget(row=0, column=0, widget=QtWidgets.QLabel(_("Include types list"))) + add_widget(row=1, column=0, widget=self.list_include) + + add_widget(row=1, column=1, widget=self.arrows_include) + add_widget(row=1, column=2, widget=self.list_ignore) + add_widget(row=1, column=3, widget=self.arrows_exclude) + + add_widget(row=0, column=4, widget=QtWidgets.QLabel(_("Exclude types list"))) + add_widget(row=1, column=4, widget=self.list_exclude) self.layout.addWidget(grid) + # Add usage explanation to the dialog box + instructions = QtWidgets.QLabel() + instructions.setText(_( + "CAA images with an image type found in the 'Include' list will be downloaded and used " + "UNLESS they also have an image type found in the 'Exclude' list. Images with types " + "found in the 'Exclude' list will NEVER be used. Image types not appearing in the 'Include' " + "or 'Exclude' lists will not be considered when determining whether or not to download and " + "use a CAA image.\n") + ) + instructions.setWordWrap(True) + instructions.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + self.layout.addWidget(instructions) + self.buttonbox = QtWidgets.QDialogButtonBox(self) self.buttonbox.setOrientation(QtCore.Qt.Horizontal) self.buttonbox.addButton( @@ -132,8 +295,10 @@ class CAATypesSelectorDialog(QtWidgets.QDialog): StandardButton(StandardButton.HELP), QtWidgets.QDialogButtonBox.HelpRole) extrabuttons = [ - (N_("Chec&k all"), self.checkall), - (N_("&Uncheck all"), self.uncheckall), + (N_("I&nclude all"), self.move_all_to_include_list), + (N_("E&xclude all"), self.move_all_to_exclude_list), + (N_("C&lear all"), self.move_all_to_ignore_list), + (N_("Restore &Defaults"), self.reset_to_defaults), ] for label, callback in extrabuttons: button = QtWidgets.QPushButton(_(label)) @@ -148,30 +313,97 @@ class CAATypesSelectorDialog(QtWidgets.QDialog): self.buttonbox.rejected.connect(self.reject) self.buttonbox.helpRequested.connect(self.help) + self.set_buttons_enabled_state() + + def move_all_to_include_list(self): + self.list_ignore.move_all_items(self.list_include) + self.list_exclude.move_all_items(self.list_include) + self.set_buttons_enabled_state() + + def move_all_to_exclude_list(self): + self.list_ignore.move_all_items(self.list_exclude) + self.list_include.move_all_items(self.list_exclude) + self.set_buttons_enabled_state() + + def move_all_to_ignore_list(self): + self.list_include.move_all_items(self.list_ignore) + self.list_exclude.move_all_items(self.list_ignore) + self.set_buttons_enabled_state() + + def fill_lists(self, includes, excludes): + """Fill dialog listboxes. + + First clears the contents of the three listboxes, and then populates the listboxes + from the dictionary of standard CAA types, using the provided 'includes' and + 'excludes' lists to determine the appropriate list for each type. + + Arguments: + includes -- list of standard image types to place in the "Include" listbox + excludes -- list of standard image types to place in the "Exclude" listbox + """ + self.list_include.clear() + self.list_exclude.clear() + self.list_ignore.clear() + for caa_type in CAA_TYPES: + name = caa_type['name'] + title = translate_caa_type(caa_type['title']) + item = QtWidgets.QListWidgetItem(title) + item.setData(QtCore.Qt.UserRole, name) + if name in includes: + self.list_include.addItem(item) + elif name in excludes: + self.list_exclude.addItem(item) + else: + self.list_ignore.addItem(item) + def help(self): webbrowser2.goto('doc_cover_art_types') - def uncheckall(self): - self._set_checked_all(False) + def get_selected_types_include(self): + return list(self.list_include.all_items_data()) or ['front'] - def checkall(self): - self._set_checked_all(True) + def get_selected_types_exclude(self): + return list(self.list_exclude.all_items_data()) or ['none'] - def _set_checked_all(self, value): - for item in self._items.keys(): - item.setChecked(value) + def clear_focus(self, lists): + for temp_list in lists: + temp_list.clearSelection() + self.set_buttons_enabled_state() - def get_selected_types(self): - return [typ['name'] for item, typ in self._items.items() if - item.isChecked()] or ['front'] + def reset_to_defaults(self): + self.fill_lists(_CAA_IMAGE_TYPE_DEFAULT_INCLUDE, _CAA_IMAGE_TYPE_DEFAULT_EXCLUDE) + self.set_buttons_enabled_state() + + def set_buttons_enabled_state(self): + has_items_include = self.list_include.count() + has_items_exclude = self.list_exclude.count() + has_items_ignore = self.list_ignore.count() + + has_selected_include = bool(self.list_include.selectedItems()) + has_selected_exclude = bool(self.list_exclude.selectedItems()) + has_selected_ignore = bool(self.list_ignore.selectedItems()) + + # "Include" list buttons + self.arrows_include.button_add.setEnabled(has_items_ignore and has_selected_ignore) + self.arrows_include.button_add_all.setEnabled(has_items_ignore) + self.arrows_include.button_remove.setEnabled(has_items_include and has_selected_include) + self.arrows_include.button_remove_all.setEnabled(has_items_include) + + # "Exclude" list buttons + self.arrows_exclude.button_add.setEnabled(has_items_ignore and has_selected_ignore) + self.arrows_exclude.button_add_all.setEnabled(has_items_ignore) + self.arrows_exclude.button_remove.setEnabled(has_items_exclude and has_selected_exclude) + self.arrows_exclude.button_remove_all.setEnabled(has_items_exclude) @staticmethod - def run(parent=None, types=None): - if types is None: - types = [] - dialog = CAATypesSelectorDialog(parent, types) + def run(parent=None, types_include=None, types_exclude=None): + if types_include is None: + types_include = [] + if types_exclude is None: + types_exclude = [] + dialog = CAATypesSelectorDialog(parent, types_include, types_exclude) result = dialog.exec_() - return (dialog.get_selected_types(), result == QtWidgets.QDialog.Accepted) + return (dialog.get_selected_types_include(), dialog.get_selected_types_exclude(), result == QtWidgets.QDialog.Accepted) class ProviderOptionsCaa(ProviderOptions): @@ -183,10 +415,10 @@ class ProviderOptionsCaa(ProviderOptions): config.BoolOption("setting", "caa_save_single_front_image", False), config.BoolOption("setting", "caa_approved_only", False), config.BoolOption("setting", "caa_image_type_as_filename", False), - config.IntOption("setting", "caa_image_size", - _CAA_IMAGE_SIZE_DEFAULT), - config.ListOption("setting", "caa_image_types", ["front"]), + config.IntOption("setting", "caa_image_size", _CAA_IMAGE_SIZE_DEFAULT), + config.ListOption("setting", "caa_image_types", _CAA_IMAGE_TYPE_DEFAULT_INCLUDE), config.BoolOption("setting", "caa_restrict_image_types", True), + config.ListOption("setting", "caa_image_types_to_omit", _CAA_IMAGE_TYPE_DEFAULT_EXCLUDE), ] _options_ui = Ui_CaaOptions @@ -231,11 +463,11 @@ class ProviderOptionsCaa(ProviderOptions): self.ui.select_caa_types.setEnabled(enabled) def select_caa_types(self): - (types, ok) = CAATypesSelectorDialog.run( - self, config.setting["caa_image_types"]) + (types, types_to_omit, ok) = CAATypesSelectorDialog.run( + self, config.setting["caa_image_types"], config.setting["caa_image_types_to_omit"]) if ok: config.setting["caa_image_types"] = types - + config.setting["caa_image_types_to_omit"] = types_to_omit class CoverArtProviderCaa(CoverArtProvider): @@ -253,6 +485,7 @@ class CoverArtProviderCaa(CoverArtProvider): def __init__(self, coverart): super().__init__(coverart) self.caa_types = list(map(str.lower, config.setting["caa_image_types"])) + self.caa_types_to_omit = list(map(str.lower, config.setting["caa_image_types_to_omit"])) self.len_caa_types = len(self.caa_types) self.restrict_types = config.setting["caa_restrict_image_types"] @@ -261,16 +494,14 @@ class CoverArtProviderCaa(CoverArtProvider): # MB web service indicates if CAA has artwork # https://tickets.metabrainz.org/browse/MBS-4536 if 'cover-art-archive' not in self.release: - log.debug("No Cover Art Archive information for %s" - % self.release['id']) + log.debug('No Cover Art Archive information for {release_id}'.format(release_id=self.release['id'])) return False caa_node = self.release['cover-art-archive'] caa_has_suitable_artwork = caa_node['artwork'] if not caa_has_suitable_artwork: - log.debug("There are no images in the Cover Art Archive for %s" - % self.release['id']) + log.debug('There are no images in the Cover Art Archive for {release_id}'.format(release_id=self.release['id'])) return False if self.restrict_types: @@ -299,11 +530,9 @@ class CoverArtProviderCaa(CoverArtProvider): caa_has_suitable_artwork = front_in_caa or back_in_caa if not caa_has_suitable_artwork: - log.debug("There are no suitable images in the Cover Art Archive for %s" - % self.release['id']) + log.debug('There are no suitable images in the Cover Art Archive for {release_id}'.format(release_id=self.release['id'])) else: - log.debug("There are suitable images in the Cover Art Archive for %s" - % self.release['id']) + log.debug('There are suitable images in the Cover Art Archive for {release_id}'.format(release_id=self.release['id'])) return caa_has_suitable_artwork @@ -313,7 +542,7 @@ class CoverArtProviderCaa(CoverArtProvider): self.coverart.front_image_found: return False if self.restrict_types and not self.len_caa_types: - log.debug("User disabled all Cover Art Archive types") + log.debug('User disabled all Cover Art Archive types') return False return self._has_suitable_artwork @@ -346,6 +575,8 @@ class CoverArtProviderCaa(CoverArtProvider): except ValueError: self.error("Invalid JSON: %s" % (http.url().toString())) else: + if self.restrict_types: + log.debug('CAA types: included: %s, excluded: %s' % (self.caa_types, self.caa_types_to_omit,)) for image in caa_data["images"]: if config.setting["caa_approved_only"] and not image["approved"]: continue @@ -364,11 +595,18 @@ class CoverArtProviderCaa(CoverArtProvider): # only keep enabled caa types types = set(image["types"]).intersection( set(self.caa_types)) + if types and self.caa_types_to_omit: + types = not set(image["types"]).intersection( + set(self.caa_types_to_omit)) + log.debug('CAA image {status}: {image_name} {image_types}'.format( + status=('accepted' if types else 'rejected'), + image_name=image['image'], + image_types=image['types'],) + ) else: types = True if types: - urls = caa_url_fallback_list(config.setting["caa_image_size"], - image["thumbnails"]) + urls = caa_url_fallback_list(config.setting["caa_image_size"], image["thumbnails"]) if not urls or is_pdf: url = image["image"] else: